Skip to content

feat(tv): per-preset FPV player scale + camera handle fix + retro/pixel polish#64

Merged
apresmoi merged 20 commits into
mainfrom
feat/tv-fpv-polish
Jun 5, 2026
Merged

feat(tv): per-preset FPV player scale + camera handle fix + retro/pixel polish#64
apresmoi merged 20 commits into
mainfrom
feat/tv-fpv-polish

Conversation

@apresmoi
Copy link
Copy Markdown
Collaborator

@apresmoi apresmoi commented Jun 4, 2026

Summary

Post-merge polish for the /tv showcase (PR #62 landed the page itself). Adds the FPV camera that makes each TV preset feel right and fixes a latent bug in <poly-perspective-camera> that was hiding the issue.

  • <poly-perspective-camera> fix: mutating the perspective attribute at runtime was recreating the camera handle, orphaning the scene's captured reference. Every subsequent rot-x/rot-y/zoom/target update silently no-op'd because they were writing to a dead handle. Now updates the wrapper's CSS perspective in place. Pinned by 5 new tests (725 total polycss tests pass).
  • Per-preset FPV player scale: retro-stack uses 1/2 and pixel-tv uses 1/3 so those TVs read as bigger from the player's POV, without scaling the mesh DOM (large CSS scales caused compositor flicker). Cabinet / electronic / monitor keep playerScale = 1 and the prior framing.
  • Auto-pitch FPV camera: rot-x is computed from look geometry (atan2(midZ - cameraZ, back)) — small players look horizontally, tall players tilt down. Replaces the hardcoded 75° that missed the TV when the camera was short.
  • Re-spawn on mesh switch in FPV: switching presets while in FPV now detaches + recreates <poly-first-person-controls> so it re-seeds cameraOrigin from the new mesh's bbox instead of staying anchored to the previous TV.
  • Recenter on FPV → Orbit: explicitly writes target="0,0,0" on exit; removeAttribute("target") alone left the camera handle anchored to the FPV position (parseVec3(null)undefined is ignored by the update path).
  • Crouch height tuned: Ctrl ducks the camera to 70% of eye-height instead of dropping it to default 1 (turn-into-mouse).

Also includes the squashed batch of TV-page tweaks since #62 merged: receive-shadow exploration, shadow tuning, floor color match to /gallery, per-screen lift override for the retro stack's recessed bezels, polygon-index fixes for the retro stack screens, per-CRT YouTube videos, and the Orbit/FPV camera-mode pill.

Test plan

  • pnpm test (725 polycss + react/vue/core all green)
  • pnpm build (all packages + website)
  • /tv orbit view renders each preset with iframe on the screen polygon
  • Click FPV — TV sits as furniture on the floor with shadow
  • Switch preset while in FPV — no flicker, no stuck-at-prior-zoom
  • Click Orbit — TV recenters
  • Ctrl crouches without shrinking to mouse-size

apresmoi added 20 commits June 4, 2026 09:58
Adds a new public element across all three renderers for placing live
iframes in 3D space, plus a five-TV demo page.

- packages/polycss: PolyIframeElement (vanilla custom element). Mounts an
  iframe wrapper inside the parent <poly-scene>'s .polycss-scene so the
  CSS camera transform composes naturally with surrounding meshes. Same
  position/rotation/scale conventions as <poly-mesh> post-parity (world
  units, world-axis order, rotation conjugation rotateY(-rx) rotateX(-ry)
  rotateZ(-rz)). Width/height in world units; iframe centred at the
  wrapper's local origin so rotation/scale pivot at the visible centre.
- packages/react: <PolyIframe> mirror with the same props.
- packages/vue:   <PolyIframe> mirror with the same props.
- 14/8/7 tests added per package; transform math + attribute forwarding
  pinned. AGENTS.md updated with the new element + naming entry.

Bug fixes shipped alongside:
- PolyPerspectiveCameraElement / PolyOrthographicCameraElement runtime
  attribute updates now mutate the camera handle in place and re-apply
  the scene transform. Before, the elements either recreated the handle
  (orphaning the scene's pointer) or updated but never called
  applyCamera, so rot-x/rot-y/zoom changes were ignored at runtime.
- DocsHeader hides the floating search dock on /television (matches
  /gallery, /builder, /wordart).

/television demo:
- Vertical-rail TV picker (5 sets, hand-drawn SVG art per tile). ?tv=id
  query param + popstate sync so individual TVs are linkable.
- Per-TV polygon indices identify which face(s) are the screen; the page
  derives the iframe's world-unit position, rotation, width, and height
  from those polygons' vertices at load time (area-weighted normal, bbox
  in the in-plane right/up basis, small lift along the normal so the
  iframe doesn't z-fight with the screen polygon itself). Per-TV rotY +
  zoom remain hand-tuned because GLB authoring orientations differ.
- Retro Stack uses multiple screen entries — one <poly-iframe> per CRT
  is mounted dynamically and each is placed independently.
- Rename /television route + public assets to /tv (shorter, link to it
  from the docs header next to WordArt).
- Query param renamed to ?model=<id> (was ?tv=<id> — reads better
  alongside /tv).
- Brighter scene lighting (directional 4.5→6.5, ambient 0.55→0.9) plus
  a ground <poly-plane> below each TV with cast-shadow on the mesh, so
  the bottom of every TV sits visibly on the floor.

Element changes to support the floor:
- PolyShapeElement now forwards `cast-shadow` / `receive-shadow` to
  scene.add (mirrors <poly-mesh>) and `exclude-from-auto-center` so
  ground planes don't skew the scene's auto-center calculation.
- PolyShapeElement gains an attributeChangedCallback so transform attrs
  (position/scale/rotation/shadow) propagate after mount — the floor is
  re-positioned per TV from the script side once the mesh has loaded.
- PolyPlaneElement: new `offset` attribute, defaulting to 0 so the
  plane is centered at the element's local origin. The underlying
  planePolygons helper defaulted to size*2 because it was authored as
  a transform-control drag handle.

Camera-element runtime fix:
- PolyPerspectiveCameraElement / PolyOrthographicCameraElement
  attributeChangedCallback now correctly locates the scene as a
  descendant (`this.querySelector('poly-scene')`) rather than an
  ancestor. The previous walk-up version always found null and
  applyCamera was never called, so runtime rot-x/rot-y/zoom changes
  were no-ops in vanilla.

Tuning:
- TV mesh gets `auto-center` so its bbox center IS mesh-local (0,0,0).
- Floor's per-preset Z is derived from the mesh's minZ on load.
- Per-TV cam.rotY/zoom dialed in by hand for each set (pixel TV at
  rotY 245 / zoom 10 for a 3/4 view that doesn't crop).
- Retro stack screen 1: append poly 216 to the polyIndices array (user
  identified it as part of that CRT's bezel strip; was missing).
- Floor shadow attempt: receiver-shadow on the floor + scene shadow
  config wired up cleanly, but the projected shadow SVG lands at wrong
  scene coords for our auto-centered TV + excludeFromAutoCenter floor
  combination. Vanilla dropped the legacy ground-shadow fallback for
  three.js parity, so a caster with no receiver draws nothing. Leaving
  the floor as a non-receiver for now with a TODO; lighting + floor
  geometry still ship.
The receiver-shadow SVG sits at the floor plane plus `shadow.lift`
(default 0.05 world units = 2.5 CSS px). On a big 400-world-unit floor
viewed at a normal-angle camera (rotX 70), that razor-thin gap z-fought
with the floor itself and the shadow only showed from straight overhead.
A 1 world unit (50 CSS px) lift puts the shadow clearly in front at
every angle the camera reaches.
The first CRT's actual screen polygons are 212–216 — 211 is an adjacent
bezel face the iframe-placement code was averaging into the plane,
shifting the iframe off the screen. Restricting to 212–216 lands the
iframe on the visible glass.
placementFromPolygons now takes an optional liftOverride; the TvScreen
config exposes it as `lift`. The retro stack's CRT screens already
model the front glass surface, so a small negative lift (−0.6 world
units) seats the iframe inside the bezel instead of in front of it.

The default lift formula (0.2 + 0.01 * max(w,h)) still applies for
every other TV — those polygons mark the screen plane itself, so the
iframe sitting slightly in front of them reads as the picture surface.
The 'floating quad' I attributed to receive-shadow earlier turned out to
be the monitor.glb's back panel authored at an offset position — present
even without receive-shadow.

Self-shadows on textured receivers are computed correctly but the
texturedReceiver code reduces opacity to mimic three.js's 'darken to
ambient-only' (`effOp = opacity * (1 - (ambient/total)^(1/2.4))`).
With our bright light (dir 6.5, ambient 0.9) that pushed shadow alpha
to ~0.32, which read as nearly-invisible 'white-ish' on textured TVs.
Bumping the input opacity to 0.95 lands the effective alpha at ~0.55
and the self-shadows now read as actually dark.
…ead dark

- Lift 1 → 0.1 world units. The bigger value was detaching shadows
  visibly from their receivers; 0.1 (5 CSS px) still clears z-fighting
  but reads as flush with the floor / bezel.
- Ambient intensity 0.9 → 0.4. The texture-receiver code caps shadow
  alpha at `opacity * (1 - (ambient/total)^(1/2.4))` to mimic three.js's
  'darken to ambient-only' lighting model. With ambient=0.9 the cap
  pushed effective shadow alpha to ~0.32 — visibly faint / 'white-ish'
  on the TV's own faces. Lower ambient = darker possible shadows; 0.4
  hits the polycss default and lets self-shadows read as properly dark.
- Bottom-centre toggle switches between <poly-orbit-controls> and
  <poly-first-person-controls>. FPV mounts with WASD/jump/look and a
  hint label appears on the right side of the pill while active.
- Orbit zoom range widened to [1, 200] so users can zoom out far past
  each TV's default zoom.
- Drop receive-shadow from the TV mesh: the texture-receiver darkening
  reads as 'white-ish' (capped at ambient brightness) and visible
  self-shadows weren't worth the cost.
- FPV mode now matches the gallery's defaults: move-speed 30,
  jump-velocity 25, gravity 60, eye-height 6, look-sensitivity 0.15.
  Also switches camera perspective to 2000 while FPV is active (the
  same FPV_PERSPECTIVE the gallery and builder use) for a wider FOV;
  restores to 32000 when going back to Orbit.
…pawn)

Entering FPV used to drop the camera at scene origin (where the TV
sits, since the scene is auto-centered) — so the player was INSIDE the
TV with a face full of bezel.

Now matching gallery's spawn behaviour:
- Save + drop scene's auto-center attribute on entry, restore on exit.
- Compute TV bbox from the loaded mesh handle, derive a spawn target
  one mesh-span behind the TV along the current rotY look direction,
  set it on the camera so the controls' initializeOriginFromTarget
  uses that as the seed.
- Remove target on exit so orbit returns to its scene-origin pivot.
@apresmoi apresmoi merged commit cac9da3 into main Jun 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant